iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0

到目前為止,我們的 /search handler 還是直接回傳假資料。這樣不利於測試與後續演進。

今天我們要把邏輯抽離成 service 層,定義一個 SearchService 介面,handler 只負責「接請求、回應結果」,而不負責「怎麼搜」。

👉 這樣做的好處:

  • 低耦合:handler 不綁死在實作上。
  • 可替換:先用假實作(mock),之後換成 ES driver。
  • 好測試:service 可單獨測試,不依賴 HTTP。

Step 1:新增 service.go

.
├── go.mod
├── main.go
├── middleware.go
├── metrics.go
├── service.go   # ← 新增
└── ...

// service.go
package main

import "context"

// SearchResult 是單筆搜尋結果
type SearchResult struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

// SearchResponse 是搜尋回應
type SearchResponse struct {
	Query string         `json:"query"`
	Hits  []SearchResult `json:"hits"`
}

// SearchService 定義搜尋服務介面
type SearchService interface {
	Search(ctx context.Context, query string) (SearchResponse, error)
}

// FakeSearchService 是假的實作,先回固定資料
type FakeSearchService struct{}

func (s *FakeSearchService) Search(ctx context.Context, query string) (SearchResponse, error) {
	return SearchResponse{
		Query: query,
		Hits: []SearchResult{
			{ID: 1, Title: "Learning Go"},
			{ID: 2, Title: "Go Concurrency Patterns"},
		},
	}, nil
}


Step 2:修改 searchHandler

func searchHandler(searchService SearchService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		query := r.URL.Query().Get("q")
		if query == "" {
			http.Error(w, "missing query parameter: q", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
		defer cancel()

		resp, err := searchService.Search(ctx, query)
		if err != nil {
			http.Error(w, fmt.Sprintf("search error: %v", err), http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(resp); err != nil {
			http.Error(w, fmt.Sprintf("encode error: %v", err), http.StatusInternalServerError)
			return
		}
	}
}

Step 3:main.go 綁定

func main() {
	cfg := LoadConfig()

	// 初始化搜尋服務
	searchService := &FakeSearchService{}

	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)
	mux.HandleFunc("/search", searchHandler(searchService))

	// 新增 metrics endpoint
	mux.Handle("/metrics", promhttp.Handler())

	// 中介層:metrics → logging → recovery
	handler := MetricsMiddleware(LoggingMiddleware(RecoveryMiddleware(mux)))

	// 啟動 pprof (只有 build tag=debug 才會啟動)
	StartPprof()

	log.Printf("Server listening on %s", cfg.Port)
	if err := http.ListenAndServe(cfg.Port, handler); err != nil {
		log.Fatal(err)
	}
}

Step 4:測試 service

新增 service_test.go

package main

import (
	"context"
	"testing"
	"time"
)

func TestFakeSearchService(t *testing.T) {
	svc := &FakeSearchService{}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	resp, err := svc.Search(ctx, "golang")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if resp.Query != "golang" {
		t.Errorf("got query=%s, want golang", resp.Query)
	}
	if len(resp.Hits) != 2 {
		t.Errorf("expected 2 hits, got %d", len(resp.Hits))
	}
}


Step 5:跑測試

go test -v ./...

預期結果:

go test -v ./...
=== RUN   TestHealthHandler
=== RUN   TestHealthHandler/healthz_should_return_ok
=== RUN   TestHealthHandler/unknown_path_should_return_404
--- PASS: TestHealthHandler (0.00s)
    --- PASS: TestHealthHandler/healthz_should_return_ok (0.00s)
    --- PASS: TestHealthHandler/unknown_path_should_return_404 (0.00s)
=== RUN   TestRecoveryMiddleware_Returns500OnPanic
2025/09/25 09:27:58 panic: boom
--- PASS: TestRecoveryMiddleware_Returns500OnPanic (0.00s)
=== RUN   TestBackoff_NoJitter
--- PASS: TestBackoff_NoJitter (0.00s)
=== RUN   TestIsRetryable_HTTP
--- PASS: TestIsRetryable_HTTP (0.00s)
=== RUN   TestIsRetryable_netError
--- PASS: TestIsRetryable_netError (0.00s)
=== RUN   TestRetry_SucceedsAfterRetries
--- PASS: TestRetry_SucceedsAfterRetries (0.00s)
=== RUN   TestRetry_StopsOnNonRetryable
--- PASS: TestRetry_StopsOnNonRetryable (0.00s)
=== RUN   TestRetry_CancelContext
--- PASS: TestRetry_CancelContext (0.02s)
=== RUN   TestSearchHandler
=== RUN   TestSearchHandler/valid_query
=== RUN   TestSearchHandler/missing_query
--- PASS: TestSearchHandler (0.00s)
    --- PASS: TestSearchHandler/valid_query (0.00s)
    --- PASS: TestSearchHandler/missing_query (0.00s)
=== RUN   TestFakeSearchService
--- PASS: TestFakeSearchService (0.00s)
=== RUN   TestRunWorkerPool_AllSuccess
--- PASS: TestRunWorkerPool_AllSuccess (0.00s)
=== RUN   TestRunWorkerPool_WithError
--- PASS: TestRunWorkerPool_WithError (0.00s)
=== RUN   TestRunWorkerPool_CancelEarly
--- PASS: TestRunWorkerPool_CancelEarly (0.00s)
PASS
ok  	github.com/arealclimber/cloud-native-search	(cached)

小結

今天完成:

  • /search handler 改成呼叫 SearchService
  • 定義 SearchService interface,先做 FakeSearchService 假實作
  • handler 不再依賴「假資料」,而是依賴 service → 解耦成功
  • 驗證 service 有正確回傳假資料

👉 明天我們會寫 整合測試:模擬一個假的 ES,在 E2E 測試中跑 /search API,確保 handler + service + middleware 能一起動。


上一篇
Day 11 - 指標輸出:QPS / Latency
下一篇
Day 13 - 整合測試:以假 ES 寫 E2E
系列文
用 Golang + Elasticsearch + Kubernetes 打造雲原生搜尋服務14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言